commit fa6eeb25b992e8ea1a9599664e221a32204350a4 Author: dev Date: Thu Sep 24 19:46:35 2026 +0800 baseline: 音乐在线电台 v1(重构前基线) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e274ec6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig — 统一跨编辑器代码风格 +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{json,jsonc}] +indent_size = 2 + +[*.html] +indent_size = 4 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1b9b63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 依赖 +node_modules/ + +# 运行时生成的 HLS 产物(可随时重建) +hls/ +hls_cache/ + +# 运行时数据(数据库) +*.db +*.db-wal +*.db-shm + +# 旧的 JSON 数据(已废弃) +playlist.json +heat.json +metadata.json + +# 本地化封面与第三方前端资源 +public/covers/ +public/vendor/ + +# 上传临时目录 +tmp/ +uploads/ + +# 日志与临时文件 +*.log +npm-debug.log* +*.tmp + +# 编辑器 / 系统 +.vscode/ +.idea/ +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..de14c0c --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# 音乐在线电台 + +把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成一条永不停播的直播流,并提供 Web 控制面板管理播放列表、从网易云导入音源。前后端一体的 Node.js 应用,无数据库依赖。 + +## 功能特性 + +- 🎵 音频文件自动转码切片(HLS),拼成 24 小时不间断的直播流 +- 📋 拖拽排序的播放列表(支持添加、移除、清空、切歌) +- 🔥 热度统计 + 缓存自动清理(上限 1GB,按热度淘汰) +- ☁️ 网易云音源导入:输入歌曲 ID,一键下载入库并显示封面/歌名/作者 +- 🖼️ 资源卡片展示:封面图高斯模糊背景 + 时长/大小信息(hover 显示) +- 🔒 可选 API 鉴权(Bearer Token) + +## 环境要求 + +| 依赖 | 版本要求 | +|------|----------| +| Node.js | >= 14.14 | +| npm | 随 Node 附带 | +| 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 +``` + +- **控制面板**:浏览器打开 +- **直播流地址**:(可用 VLC、PotPlayer、ffplay 等播放器打开) + +停止服务:在终端按 `Ctrl + C`。 + +> 💡 Windows 下如果端口被旧进程占用导致启动失败(`EADDRINUSE`),见下文「常见问题」。 + +## 使用指南 + +### 添加音频 + +把音频文件(支持 `.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 # 歌曲元数据(歌名/作者/封面图/时长) +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `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`。 + +## 技术栈 + +Node.js · Express · Chokidar · FFmpeg(HLS 切片)· SortableJS · Bootstrap Icons diff --git a/index.js b/index.js new file mode 100644 index 0000000..a8c6789 --- /dev/null +++ b/index.js @@ -0,0 +1,1233 @@ +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))); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c20a2af --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1022 @@ +{ + "name": "music-online-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "music-online-demo", + "version": "1.0.0", + "dependencies": { + "chokidar": "^3.6.0", + "express": "^4.21.2" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b32b18 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "music-online-demo", + "version": "1.0.0", + "description": "音乐在线电台:FFmpeg HLS 直播流服务", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "engines": { + "node": ">=14.14.0" + }, + "dependencies": { + "chokidar": "^3.6.0", + "express": "^4.21.2" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..ff7ac98 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,1750 @@ + /* ==================== TOKENS ==================== */ + :root { + --bg: #f4f5f9; + --surface: #fff; + --surface-hover: #fafbff; + --accent: #4f6ef7; + --accent-hover: #3d5ce0; + --accent-light: #eef1fe; + --accent-glow: rgba(79, 110, 247, 0.14); + --accent-subtle: rgba(79, 110, 247, 0.06); + --text-primary: #0f1225; + --text-secondary: #5a607f; + --text-muted: #9499b8; + --border: #e8eaf2; + --border-light: #f0f1f6; + --danger: #ef4565; + --danger-light: #fef0f2; + --success: #30a46c; + --success-light: #e9f9ef; + --warning: #f5a623; + --warning-light: #fff7e8; + --radius-sm: 12px; + --radius: 16px; + --radius-lg: 24px; + --radius-xl: 28px; + --shadow-xs: 0 1px 2px rgba(15, 18, 37, 0.04); + --shadow-sm: 0 2px 8px rgba(15, 18, 37, 0.06); + --shadow: 0 4px 20px rgba(15, 18, 37, 0.07); + --shadow-lg: 0 8px 40px rgba(15, 18, 37, 0.1); + --content-w: 920px; + --nav-bottom: 14px; + --nav-h: 56px; + --mini-h: 0px; + --total-bottom: calc( + var(--nav-bottom) + var(--nav-h) + var(--mini-h) + 14px + ); + } + .mini-player-visible { + --mini-h: 82px; + } + + /* ==================== RESET ==================== */ + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + body { + font-family: + "Plus Jakarta Sans", + -apple-system, + BlinkMacSystemFont, + sans-serif; + color: var(--text-primary); + height: 100dvh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; + overflow: hidden; + } + img { + display: block; + max-width: 100%; + height: auto; + } + button { + font-family: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + input { + font-family: inherit; + } + + /* ==================== BG ==================== */ + .page-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient( + ellipse at 20% 0%, + rgba(79, 110, 247, 0.04) 0%, + transparent 50% + ), + radial-gradient( + ellipse at 80% 100%, + rgba(139, 92, 246, 0.03) 0%, + transparent 50% + ), + var(--bg); + } + .bg-shapes { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + } + .bg-shapes .s { + position: absolute; + border-radius: 50%; + opacity: 0.03; + animation: floatShape 22s ease-in-out infinite alternate; + } + .bg-shapes .s:nth-child(1) { + width: 520px; + height: 520px; + top: -15%; + left: -10%; + background: var(--accent); + } + .bg-shapes .s:nth-child(2) { + width: 420px; + height: 420px; + bottom: -10%; + right: -8%; + background: #8b5cf6; + animation-delay: -6s; + } + .bg-shapes .s:nth-child(3) { + width: 300px; + height: 300px; + top: 40%; + left: 60%; + background: var(--success); + animation-delay: -12s; + } + @keyframes floatShape { + 0% { + transform: translate(0, 0) scale(1); + } + 100% { + transform: translate(40px, -30px) scale(1.12); + } + } + + /* ==================== HEADER ==================== */ + .top-header { + flex-shrink: 0; + z-index: 100; + background: rgba(244, 245, 249, 0.78); + backdrop-filter: blur(28px) saturate(180%); + padding: 0.55rem 1rem; + animation: headerSlide 0.5s cubic-bezier(0.22, 1, 0.36, 1); + } + @keyframes headerSlide { + from { + opacity: 0; + transform: translateY(-100%); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .header-inner { + max-width: var(--content-w); + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + } + .brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 800; + font-size: 0.98rem; + color: var(--text-primary); + letter-spacing: -0.02em; + } + .brand-icon { + width: 30px; + height: 30px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 2px 12px rgba(79, 110, 247, 0.3); + flex-shrink: 0; + } + .brand-icon img { + width: 100%; + height: 100%; + object-fit: cover; + } + .live-pill { + display: inline-flex; + align-items: center; + gap: 0.28rem; + font-size: 0.56rem; + font-weight: 700; + background: var(--success); + color: #fff; + padding: 0.18rem 0.5rem; + border-radius: 20px; + letter-spacing: 0.06em; + text-transform: uppercase; + animation: livePulse 2.5s ease-in-out infinite; + } + .live-dot { + width: 4px; + height: 4px; + background: #fff; + border-radius: 50%; + animation: dotPulse 1.5s ease-in-out infinite; + } + @keyframes livePulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(48, 164, 108, 0.35); + } + 50% { + box-shadow: 0 0 0 8px rgba(48, 164, 108, 0); + } + } + @keyframes dotPulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.3; + transform: scale(0.8); + } + } + .header-actions { + display: flex; + align-items: center; + gap: 0.45rem; + } + .cache-pill { + font-size: 0.66rem; + font-weight: 500; + color: var(--text-secondary); + background: var(--surface); + padding: 0.22rem 0.55rem; + border-radius: 20px; + display: inline-flex; + align-items: center; + gap: 0.28rem; + border: 1px solid var(--border-light); + font-family: "JetBrains Mono", monospace; + letter-spacing: -0.02em; + } + .btn-link-sm { + font-size: 0.7rem; + font-weight: 600; + color: var(--accent); + text-decoration: none; + padding: 0.28rem 0.6rem; + border-radius: 10px; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .btn-link-sm:hover { + background: var(--accent-light); + } + + /* ==================== PAGE CONTAINER ==================== */ + .page-container { + position: relative; + z-index: 1; + max-width: var(--content-w); + margin: 0 auto; + width: 100%; + padding: 1rem 1.25rem 2rem; + padding-bottom: calc(var(--total-bottom) + 1.5rem); + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + .page-container::-webkit-scrollbar { + width: 5px; + } + .page-container::-webkit-scrollbar-track { + background: transparent; + } + .page-container::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; + } + .page-container::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } + .page { + display: none; + } + .page.active { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + animation: pageEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; + } + @keyframes pageEnter { + from { + opacity: 0; + transform: translateY(12px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + /* ==================== CARDS ==================== */ + .section-card, + .search-card, + .about-card { + background: var(--surface); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + border: 1px solid var(--border-light); + animation: cardRise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; + } + .section-card { + display: flex; + flex-direction: column; + min-height: 0; + } + @keyframes cardRise { + from { + opacity: 0; + transform: translateY(18px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* ==================== SEARCH ==================== */ + .search-card { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + } + .search-header { + padding: 1.5rem 1.5rem 0.3rem; + } + .search-header h2 { + font-size: 1.12rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 0.12rem; + } + .search-header p { + font-size: 0.78rem; + color: var(--text-muted); + } + .search-input-wrap { + padding: 0.75rem 1.5rem; + } + .search-field { + display: flex; + gap: 0.5rem; + align-items: stretch; + } + .search-field input { + flex: 1; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.65rem 0.85rem; + font-size: 0.84rem; + background: var(--bg); + color: var(--text-primary); + outline: none; + transition: + border-color 0.25s, + box-shadow 0.25s, + background 0.25s; + } + .search-field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + background: var(--surface); + } + .search-field input::placeholder { + color: var(--text-muted); + font-size: 0.82rem; + } + .btn-search { + border: none; + background: var(--accent); + color: #fff; + padding: 0 1.2rem; + border-radius: var(--radius-sm); + font-weight: 700; + font-size: 0.82rem; + transition: all 0.2s; + white-space: nowrap; + letter-spacing: -0.01em; + position: relative; + overflow: hidden; + } + .btn-search::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.2); + transform: translateX(-100%); + transition: transform 0.3s; + } + .btn-search:hover::after { + transform: translateX(0); + } + .btn-search:hover { + background: var(--accent-hover); + box-shadow: 0 4px 16px rgba(79, 110, 247, 0.25); + } + .btn-search:active { + transform: scale(0.97); + } + .btn-search:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Parse hint */ + .parse-hint { + padding: 0 1.5rem; + margin-bottom: 0.3rem; + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.68rem; + color: var(--text-muted); + max-height: 0; + opacity: 0; + overflow: hidden; + transition: all 0.3s ease; + } + .parse-hint.visible { + opacity: 1; + max-height: 36px; + padding-top: 0.1rem; + } + .parse-hint i { + color: var(--accent); + font-size: 0.75rem; + } + .parse-hint code { + font-family: "JetBrains Mono", monospace; + font-size: 0.64rem; + background: var(--accent-light); + color: var(--accent); + padding: 0.08rem 0.35rem; + border-radius: 5px; + font-weight: 500; + } + + /* Preview card — info only, no inline player */ + .preview-card { + margin: 0 1.5rem 1.5rem; + background: var(--bg); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--border-light); + animation: slideUp 0.4s cubic-bezier(0.22, 1, 0.36, 1); + } + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .preview-cover-section { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.9rem 1.1rem; + } + .preview-cover-wrap { + position: relative; + width: 64px; + height: 64px; + flex-shrink: 0; + } + .preview-cover { + width: 64px; + height: 64px; + border-radius: var(--radius); + object-fit: cover; + box-shadow: var(--shadow-sm); + } + .preview-cover-wrap::after { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--radius); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06); + pointer-events: none; + } + .preview-info { + flex: 1; + min-width: 0; + } + .preview-title { + font-weight: 700; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.02em; + margin-bottom: 0.1rem; + } + .preview-artist { + font-size: 0.78rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .preview-details { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 1.1rem 0.9rem; + } + .detail-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.66rem; + font-weight: 500; + color: var(--text-secondary); + background: var(--surface); + padding: 0.24rem 0.55rem; + border-radius: 10px; + border: 1px solid var(--border-light); + } + .detail-tag i { + font-size: 0.7rem; + color: var(--text-muted); + } + .detail-tag.album-tag i { + color: var(--accent); + } + .detail-tag.quality-tag i { + color: var(--success); + } + .detail-tag.duration-tag i { + color: var(--warning); + } + .preview-actions { + display: flex; + gap: 0.5rem; + padding: 0 1.1rem 1.1rem; + } + .btn-download { + border: none; + background: var(--accent); + color: #fff; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font-weight: 700; + font-size: 0.78rem; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + letter-spacing: -0.01em; + flex: 1; + justify-content: center; + position: relative; + overflow: hidden; + } + .btn-download::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.2); + transform: translateX(-100%); + transition: transform 0.3s; + } + .btn-download:hover::after { + transform: translateX(0); + } + .btn-download:hover { + background: var(--accent-hover); + box-shadow: 0 4px 16px rgba(79, 110, 247, 0.25); + } + .btn-download:active { + transform: scale(0.97); + } + .btn-download:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Search results */ + .searchResults { + margin: 0 1.5rem 1.5rem; + animation: slideUp 0.4s cubic-bezier(0.22, 1, 0.36, 1); + } + .sr-head { + font-size: 0.74rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + padding: 0 0.25rem; + } + .sr-head b { + color: var(--accent); + font-weight: 700; + } + .sr-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + } + .sr-item { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.75rem; + background: var(--bg); + border: 1.5px solid transparent; + border-radius: var(--radius); + cursor: pointer; + transition: all 0.25s ease; + opacity: 0; + animation: srFadeIn 0.35s ease forwards; + } + .sr-item:hover { + border-color: var(--accent); + box-shadow: 0 2px 10px rgba(79, 110, 247, 0.12); + transform: translateY(-1px); + } + .sr-item.active { + border-color: var(--accent); + background: var(--accent-light); + } + @keyframes srFadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .sr-main { + flex: 1; + min-width: 0; + } + .sr-name { + font-weight: 600; + font-size: 0.84rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .sr-artist { + font-size: 0.72rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.06rem; + } + .sr-alias { + font-size: 0.66rem; + color: var(--text-muted); + background: var(--surface); + padding: 0.15rem 0.4rem; + border-radius: 7px; + border: 1px solid var(--border-light); + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .sr-duration { + font-family: "JetBrains Mono", monospace; + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; + } + + /* Tips — expand on tall screens */ + .search-tips { + padding: 2rem 1.5rem 2.5rem; + text-align: center; + } + .tips-icon { + width: 60px; + height: 60px; + background: var(--accent-light); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 0.9rem; + font-size: 1.3rem; + color: var(--accent); + animation: float 3s ease-in-out infinite; + } + @keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-7px); + } + } + .tips-title { + font-weight: 700; + font-size: 0.9rem; + margin-bottom: 0.3rem; + letter-spacing: -0.02em; + } + .tips-desc { + font-size: 0.76rem; + color: var(--text-muted); + line-height: 1.7; + } + .tips-desc code { + font-family: "JetBrains Mono", monospace; + font-size: 0.68rem; + background: var(--accent-light); + color: var(--accent); + padding: 0.08rem 0.3rem; + border-radius: 5px; + font-weight: 500; + } + + /* ==================== RESOURCE / PLAYLIST ==================== */ + .section-header { + padding: 1.1rem 1.1rem 0.65rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + flex-shrink: 0; + } + .section-title { + font-size: 0.92rem; + font-weight: 800; + display: flex; + align-items: center; + gap: 0.4rem; + letter-spacing: -0.03em; + } + .section-count { + min-width: 20px; + height: 20px; + background: var(--accent-light); + color: var(--accent); + font-size: 0.62rem; + font-weight: 700; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 5px; + } + .section-body { + padding: 0 0.5rem 0.5rem 0.5rem; + margin-bottom: 0.5rem; + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + .section-body::-webkit-scrollbar { + width: 5px; + } + .section-body::-webkit-scrollbar-track { + background: transparent; + } + .section-body::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; + } + .section-body::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } + .resource-search { + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.35rem 0.65rem; + font-size: 0.74rem; + background: var(--bg); + color: var(--text-primary); + outline: none; + width: 135px; + transition: + border-color 0.25s, + box-shadow 0.25s, + width 0.25s; + } + .resource-search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + width: 175px; + } + .resource-search::placeholder { + color: var(--text-muted); + } + + /* Resource item */ + .res-item { + position: relative; + overflow: hidden; + border-radius: var(--radius-lg); + margin: 0.2rem 0; + transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); + cursor: default; + opacity: 0; + animation: itemFadeIn 0.4s ease forwards; + } + .res-item:hover { + box-shadow: var(--shadow-sm); + transform: translateY(-2px); + } + .res-item:active { + transform: translateY(0); + } + .res-bg { + position: absolute; + inset: -40px; + background-size: cover; + background-position: center; + filter: blur(50px) saturate(1.3); + opacity: 0.07; + z-index: 0; + transition: opacity 0.35s; + } + .res-item:hover .res-bg { + opacity: 0.15; + } + .res-row { + position: relative; + z-index: 1; + display: flex; + align-items: center; + padding: 0.6rem 0.75rem; + } + .res-cover { + width: 44px; + height: 44px; + border-radius: 11px; + object-fit: cover; + flex-shrink: 0; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); + } + .res-cover-ph { + width: 44px; + height: 44px; + border-radius: 11px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: var(--bg); + font-size: 1rem; + border: 1px solid var(--border-light); + } + .res-meta { + flex: 1; + min-width: 0; + margin: 0 0.65rem; + } + .res-title { + font-weight: 700; + font-size: 0.83rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; + } + .res-artist { + font-size: 0.71rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.08rem; + } + .res-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + } + .heat-chip { + font-size: 0.62rem; + font-weight: 600; + background: var(--warning-light); + color: var(--warning); + padding: 0.14rem 0.4rem; + border-radius: 7px; + display: inline-flex; + align-items: center; + gap: 0.18rem; + } + .btn-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + font-size: 1rem; + border-radius: 9px; + transition: all 0.2s; + } + .btn-icon:hover { + background: var(--accent-light); + color: var(--accent); + } + .btn-icon:active { + transform: scale(0.85); + } + .btn-icon.danger:hover { + background: var(--danger-light); + color: var(--danger); + } + .res-detail { + position: relative; + z-index: 1; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.35s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.3s, + padding 0.35s; + font-size: 0.68rem; + color: var(--text-secondary); + padding: 0 0.75rem 0 calc(0.75rem + 44px + 0.65rem); + } + .res-item:hover .res-detail { + max-height: 36px; + opacity: 1; + padding-bottom: 0.45rem; + } + .res-detail span { + display: inline-flex; + align-items: center; + gap: 0.15rem; + } + .detail-sep { + color: var(--border); + margin: 0 0.3rem; + } + + /* Playlist item */ + .pl-item { + position: relative; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-lg); + margin: 0.2rem 0; + transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1); + cursor: grab; + opacity: 0; + animation: itemFadeIn 0.4s ease forwards; + } + .pl-item:active { + cursor: grabbing; + } + .pl-item:hover { + background: var(--bg); + transform: translateY(-1px); + } + .pl-grip { + color: var(--text-muted); + font-size: 0.8rem; + flex-shrink: 0; + opacity: 0.3; + transition: opacity 0.2s; + } + .pl-item:hover .pl-grip { + opacity: 0.7; + } + .pl-num { + width: 20px; + height: 20px; + background: var(--accent-light); + color: var(--accent); + font-size: 0.6rem; + font-weight: 700; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-family: "JetBrains Mono", monospace; + } + .pl-info { + flex: 1; + min-width: 0; + } + .pl-name { + font-weight: 700; + font-size: 0.82rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; + } + .pl-sub { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.12rem; + } + .pl-cache { + font-size: 0.6rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.18rem; + padding: 0.08rem 0.35rem; + border-radius: 6px; + } + .pl-cache.ready { + background: var(--success-light); + color: var(--success); + } + .pl-cache.pending { + background: var(--warning-light); + color: var(--warning); + } + .pl-cache.none { + background: var(--bg); + color: var(--text-muted); + } + .pl-heat { + font-size: 0.6rem; + } + .btn-clear { + border: none; + background: var(--danger-light); + color: var(--danger); + font-size: 0.68rem; + font-weight: 700; + padding: 0.3rem 0.7rem; + border-radius: 10px; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .btn-clear:hover { + background: #fdd; + box-shadow: 0 2px 8px rgba(239, 69, 101, 0.15); + } + .btn-clear:active { + transform: scale(0.97); + } + + /* Sortable */ + .sortable-ghost { + opacity: 0.3; + background: var(--accent-light) !important; + border-radius: var(--radius-lg); + } + + /* ==================== ABOUT PAGE ==================== */ + .about-card { + display: flex; + flex-direction: column; + min-height: 0; + } + .about-hero { + text-align: center; + padding: 2.2rem 1.5rem 1.8rem; + background: linear-gradient( + 135deg, + var(--accent-light) 0%, + rgba(139, 92, 246, 0.06) 100% + ); + flex-shrink: 0; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + } + .about-logo { + width: 72px; + height: 72px; + border-radius: 18px; + overflow: hidden; + margin: 0 auto 1rem; + box-shadow: 0 4px 24px rgba(79, 110, 247, 0.25); + } + .about-logo img { + width: 100%; + height: 100%; + object-fit: cover; + } + .about-hero h2 { + font-size: 1.25rem; + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 0.15rem; + } + .about-hero p { + font-size: 0.82rem; + color: var(--text-secondary); + } + .about-body { + padding: 0.5rem 1.5rem 1.8rem; + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + .about-body::-webkit-scrollbar { + width: 5px; + } + .about-body::-webkit-scrollbar-track { + background: transparent; + } + .about-body::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; + } + .about-body::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } + .about-section { + display: flex; + gap: 0.75rem; + padding: 0.9rem 0; + border-bottom: 1px solid var(--border-light); + } + .about-section:last-child { + border-bottom: none; + } + .about-section-icon { + width: 34px; + height: 34px; + background: var(--accent-light); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + font-size: 0.95rem; + flex-shrink: 0; + } + .about-section-content h3 { + font-size: 0.82rem; + font-weight: 700; + margin-bottom: 0.25rem; + } + .about-section-content p { + font-size: 0.76rem; + color: var(--text-secondary); + line-height: 1.7; + } + .tech-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + .tech-tags span { + font-size: 0.68rem; + font-weight: 600; + background: var(--bg); + color: var(--text-secondary); + padding: 0.2rem 0.55rem; + border-radius: 8px; + border: 1px solid var(--border-light); + } + .disclaimer-section .about-section-icon { + background: var(--warning-light); + color: var(--warning); + } + .about-version { + text-align: center; + padding: 0.8rem; + font-size: 0.65rem; + color: var(--text-muted); + font-family: "JetBrains Mono", monospace; + letter-spacing: -0.01em; + flex-shrink: 0; + } + + /* ==================== ABOUT — EXTENSIONS ==================== */ + /* 核心功能列表 */ + .about-feature-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.2rem; + } + + .about-feat { + display: flex; + align-items: flex-start; + gap: 0.45rem; + font-size: 0.76rem; + color: var(--text-secondary); + line-height: 1.65; + padding: 0.3rem 0; + } + + .about-feat i { + font-size: 0.72rem; + flex-shrink: 0; + margin-top: 0.18rem; + } + + /* GitHub 链接徽章 */ + .about-gh-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border-light); + border-radius: 10px; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s; + font-family: "JetBrains Mono", monospace; + letter-spacing: -0.01em; + } + + .about-gh-link:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-light); + } + + .about-gh-link i:first-child { + font-size: 0.9rem; + } + + /* ==================== MINI PLAYER ==================== */ + .mini-player { + position: fixed; + bottom: calc(var(--nav-bottom) + var(--nav-h) + 12px); + left: 50%; + transform: translateX(-50%); + width: min(var(--content-w), calc(100% - 2rem)); + z-index: 99; + border-radius: 20px; + overflow: hidden; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(28px) saturate(200%); + border: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: + 0 4px 24px rgba(15, 18, 37, 0.1), + 0 1px 4px rgba(15, 18, 37, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + opacity: 0; + pointer-events: none; + transform: translateX(-50%) translateY(16px) scale(0.96); + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .mini-player.show { + opacity: 1; + pointer-events: auto; + transform: translateX(-50%) translateY(0) scale(1); + } + /* progress = full background */ + .mp-progress { + position: absolute; + inset: 0; + width: 0; + background: linear-gradient( + 90deg, + rgba(79, 110, 247, 0.11), + rgba(139, 92, 246, 0.08) + ); + pointer-events: none; + z-index: 0; + transition: width 0.15s linear; + } + .mini-player:hover .mp-progress { + background: linear-gradient( + 90deg, + rgba(79, 110, 247, 0.16), + rgba(139, 92, 246, 0.12) + ); + } + .mp-body { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.9rem 0.55rem; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + } + .mp-cover { + width: 40px; + height: 40px; + border-radius: 9px; + object-fit: cover; + flex-shrink: 0; + box-shadow: var(--shadow-xs); + } + .mp-info { + flex: 1; + min-width: 0; + } + .mp-name { + font-weight: 700; + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; + } + .mp-artist { + font-size: 0.68rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.04rem; + } + .mp-controls { + display: flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + } + .mp-time { + font-family: "JetBrains Mono", monospace; + font-size: 0.64rem; + color: var(--text-muted); + flex-shrink: 0; + min-width: 40px; + text-align: center; + } + .mp-play { + width: 34px; + height: 34px; + border: none; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 0.95rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .mp-play:hover { + background: var(--accent-hover); + box-shadow: 0 2px 8px rgba(79, 110, 247, 0.3); + } + .mp-play:active { + transform: scale(0.88); + } + .mp-expand { + width: 30px; + height: 30px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .mp-expand:hover { + background: var(--accent-light); + color: var(--accent); + } + + /* ==================== BOTTOM NAV ==================== */ + .bottom-nav { + position: fixed; + bottom: var(--nav-bottom); + left: 50%; + transform: translateX(-50%); + z-index: 100; + pointer-events: none; + animation: navSlideUp 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.15s + both; + } + @keyframes navSlideUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(24px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + .bottom-nav-inner { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(32px) saturate(220%); + -webkit-backdrop-filter: blur(32px) saturate(220%); + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 22px; + box-shadow: + 0 2px 12px rgba(15, 18, 37, 0.07), + 0 0 0 0.5px rgba(15, 18, 37, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 0.9); + } + .nav-tab { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 18px; + border: none; + background: none; + color: var(--text-muted); + font-size: 0.56rem; + font-weight: 600; + letter-spacing: 0.03em; + border-radius: 16px; + z-index: 1; + transition: + color 0.3s ease, + transform 0.2s ease; + } + .nav-tab i { + font-size: 1.25rem; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + position: relative; + z-index: 1; + line-height: 1; + } + .nav-tab span { + position: relative; + z-index: 1; + line-height: 1; + } + /* active pill */ + .nav-tab::before { + content: ""; + position: absolute; + inset: 0; + background: var(--accent); + border-radius: 16px; + opacity: 0; + transform: scale(0.6); + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 0; + } + .nav-tab.active { + color: #fff; + } + .nav-tab.active::before { + opacity: 1; + transform: scale(1); + } + .nav-tab.active i { + transform: scale(1.1) translateY(-1px); + } + .nav-tab:hover:not(.active) { + color: var(--text-secondary); + } + .nav-tab:hover:not(.active)::before { + opacity: 0.04; + transform: scale(0.85); + background: var(--accent); + } + .nav-tab:active { + transform: scale(0.92); + } + + /* ==================== EMPTY / SCROLL ==================== */ + .empty-state { + text-align: center; + padding: 2rem 1.5rem; + color: var(--text-muted); + } + .empty-icon { + width: 48px; + height: 48px; + background: var(--bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 0.75rem; + font-size: 1.1rem; + color: var(--text-muted); + border: 1px solid var(--border-light); + animation: float 3.5s ease-in-out infinite; + } + .empty-title { + font-weight: 700; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 0.15rem; + } + .empty-desc { + font-size: 0.74rem; + } + + /* ==================== TOAST ==================== */ + .toast-container { + position: fixed; + top: 68px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.4rem; + pointer-events: none; + } + .toast-msg { + background: var(--text-primary); + color: #fff; + padding: 0.5rem 1.1rem; + border-radius: var(--radius-sm); + font-size: 0.78rem; + font-weight: 600; + box-shadow: var(--shadow-lg); + animation: + toastIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards, + toastOut 0.3s ease 2.5s forwards; + pointer-events: auto; + white-space: nowrap; + letter-spacing: -0.01em; + } + .toast-msg.error { + background: var(--danger); + } + .toast-msg.success { + background: var(--success); + } + @keyframes toastIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + @keyframes toastOut { + from { + opacity: 1; + } + to { + opacity: 0; + transform: translateY(-8px) scale(0.95); + } + } + + /* ==================== ANIMATIONS ==================== */ + @keyframes itemFadeIn { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes itemFadeOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-5px); + } + } + .res-item.enter, + .pl-item.enter { + animation: itemFadeIn 0.35s ease forwards; + } + .res-item.leave, + .pl-item.leave { + animation: itemFadeOut 0.25s ease forwards; + } + .spinner-sm { + width: 15px; + height: 15px; + border: 2.5px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; + display: inline-block; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + /* ==================== RESPONSIVE ==================== */ + /* Tablet */ + @media (min-width: 768px) { + .page-container { + padding: 1.2rem 2rem 2rem; + padding-bottom: calc(var(--total-bottom) + 2rem); + } + .res-detail { + display: block; + } + .search-header { + padding: 1.8rem 2rem 0.4rem; + } + .search-input-wrap { + padding: 0.8rem 2rem; + } + .preview-card { + margin: 0 2rem 2rem; + } + .searchResults { + margin: 0 2rem 2rem; + } + .parse-hint { + padding: 0 2rem; + } + .search-tips { + padding: 2.5rem 2rem 3rem; + } + .about-body { + padding: 0.5rem 2rem 2rem; + } + } + /* Large desktop */ + @media (min-width: 1200px) { + :root { + --content-w: 960px; + } + .mini-player { + width: min(960px, calc(100% - 3rem)); + } + } + /* Small phones */ + @media (max-width: 380px) { + :root { + --nav-bottom: 10px; + } + .nav-tab { + font-size: 0.54rem; + } + .nav-tab i { + font-size: 1.05rem; + } + .cache-pill { + display: none; + } + .search-header { + padding: 1.1rem 1.1rem 0.25rem; + } + .search-input-wrap { + padding: 0.6rem 1.1rem; + } + .preview-card { + margin: 0 1.1rem 1.1rem; + } + .searchResults { + margin: 0 1.1rem 1.1rem; + } + .parse-hint { + padding: 0 1.1rem; + } + .about-body { + padding: 0.4rem 1.1rem 1.2rem; + } + } + /* Landscape phone */ + @media (max-height: 500px) and (orientation: landscape) { + .top-header { + padding: 0.3rem 0.8rem; + } + .page-container { + padding: 0.5rem 1rem 1rem; + } + .search-tips { + padding: 0.8rem; + } + .tips-icon { + width: 40px; + height: 40px; + font-size: 0.9rem; + margin-bottom: 0.5rem; + } + .preview-cover-section { + padding: 0.6rem 0.8rem; + } + .preview-cover, + .preview-cover-wrap { + width: 48px; + height: 48px; + } + .sr-list, + .scroll-area { + max-height: 38vh; + } + .about-hero { + padding: 1.2rem 1rem 1rem; + } + .about-logo { + width: 52px; + height: 52px; + } + } + /* Reduced motion */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..610ec3f Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..b818748 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e954980 --- /dev/null +++ b/public/index.html @@ -0,0 +1,397 @@ + + + + + + + Xiao の 电台 + + + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+
+
+ logo +
+ Xiao の 电台 + LIVE +
+
+ -- + 直播流 +
+
+
+ + +
+ +
+
+
+

导入音源

+

输入网易云歌曲 ID、链接或歌名查询并下载

+
+
+
+ + +
+
+
+ + 已自动解析为 ID: +
+ + + +
+
+ +
+
搜索并下载音乐
+
+ 在网易云音乐中复制歌曲 ID 或完整链接
粘贴到上方输入框即可自动识别 +
+
+
+
+ + +
+
+
+
+ + 已有音乐 + 0 +
+ +
+
+
+
+ +
+
加载中...
+
+
+
+
+ + +
+
+
+
+ + 播放列表 + 0 +
+ +
+
+
+
+ +
+
加载中...
+
+
+
+
+ + +
+
+ +
+ +

Xiao の 电台

+

基于 HLS 的个人在线音乐电台

+
+ + +
+ +
+
+ +
+
+

项目简介

+

+ 一个基于 Web 的个人在线音乐电台,支持通过网易云音乐歌曲 ID、链接或歌名进行搜索、试听与下载,提供 HLS 直播流播放、资源管理与播放列表拖拽排序等功能。界面采用简约玻璃拟态风格,适配移动端与桌面端多种屏幕尺寸。 +

+
+
+ + +
+
+ +
+
+

核心功能

+
+
+ + 智能搜索 — 歌曲 ID、链接或歌名关键词,自动解析并一键导入 +
+
+ + 直播流 — FFmpeg HLS 切片 + 滑动窗口,5 秒精度连续输出 +
+
+ + 智能缓存 — 预生成切片、增量追踪、LRU 淘汰与自动清理 +
+
+ + 播放列表 — 拖拽排序、添加/移除/跳转,SSE 实时状态推送 +
+
+ + 全端适配 — 玻璃拟态 UI,手势滑动、键盘快捷键、拖拽进度条 +
+
+
+
+ + +
+
+ +
+
+

技术栈

+
+ HTML5 + CSS3 + JavaScript ES6+ + Bootstrap 5 + SortableJS + HLS.js + Node.js + Express + FFmpeg + Chokidar +
+
+
+ + +
+
+ +
+
+

开发语言

+

前端:HTML / CSS / JavaScript  |  后端:Node.js

+
+
+ + +
+
+ +
+
+

作者

+

Xiao — 独立开发者

+ + + Xiao-no-love + + +
+
+ + +
+
+ +
+
+

免责声明

+

+ 本项目所有音源均来自网易云音乐,仅供个人学习与技术研究使用,严禁用于任何商业用途。音乐版权归原创作者及网易云音乐平台所有。如有侵权,请联系删除。请于下载后 24 小时内清除,支持正版音乐。 +

+
+
+
+ + +
+ v1.0.0 · Built with ♥ · + GitHub +
+
+
+
+ + +
+
+
+ +
+
未在播放
+
--
+
+
+ 0:00 + + +
+
+
+ + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..5d02f4a --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,758 @@ + /* ============================================================ + Xiao の 电台 — Enhanced JS + ============================================================ */ + + // ==================== 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); + } + + // ==================== Navigation ==================== + const tabs = document.querySelectorAll(".nav-tab"); + const pages = document.querySelectorAll(".page"); + + 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) { + 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(); + } + + // ==================== 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; + } + + // ==================== 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), + ); + + // ==================== 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, + }); + } + }); + } + + // ==================== 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); + } + } + + // ==================== 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); + } + } + + // ==================== 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(); + }); + 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(); + loadPlaylist(); + loadCacheInfo(); + setInterval(() => { + loadResources(); + loadPlaylist(); + loadCacheInfo(); + }, 5000);