player / app.js
lzwqx's picture
Update app.js
9066016 verified
const express = require('express');
const path = require('path');
const fs = require('fs');
const rangeParser = require('range-parser');
const bytes = require('bytes');
const NodeCache = require('node-cache');
const axios = require('axios');
const app = express();
const PORT = process.env.PORT || 3000;
require('dotenv').config(); // 加载环境变量
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; // 管理密码
const musicDir = path.join(__dirname, process.env.MUSIC_DIR || 'music');
// 确保音乐目录存在,不存在自动创建
if (!fs.existsSync(musicDir)) {
fs.mkdirSync(musicDir, { recursive: true });
console.log(`Created music directory: ${musicDir}`);
}
function getContentType(ext) {
const contentTypes = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4'
};
return contentTypes[ext] || 'application/octet-stream';
}
// 创建缓存实例,TTL 设置为1小时
const cache = new NodeCache({
stdTTL: 7200,
checkperiod: 120,
maxKeys: 500 // 最多缓存500个文件的信息
});
// 流量统计
const stats = {
totalBytes: 0,
requests: 0
};
// JSON 格式化
app.set('json spaces', 2);
// 静态文件服务
app.use('/static', express.static(musicDir));
// CORS 中间件
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// 前端静态文件服务
app.use(express.static(path.join(__dirname, 'public')));
// 直链生成
app.get('/music/:filename', async (req, res) => {
const filename = req.params.filename;
// 检查文件名是否合法
if (!filename.match(/^[a-zA-Z0-9\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fa5\s\-_.]+\.(mp3|wav|flac|m4a)$/)) {
return res.status(400).send('Invalid filename');
}
const normalizedPath = path.normalize(filename);
if (normalizedPath.includes('..')) {
return res.status(403).send('Access denied');
}
const filepath = path.join(musicDir, filename);
// 从缓存获取文件信息
let fileInfo = cache.get(filepath);
if (!fileInfo) {
try {
const stat = await fs.promises.stat(filepath);
fileInfo = {
size: stat.size,
mtime: stat.mtime.toUTCString(),
exists: true
};
cache.set(filepath, fileInfo);
} catch (err) {
return res.status(404).send('File not found');
}
}
const range = req.headers.range;
// 通用响应头
res.set({
'Cache-Control': 'public, max-age=3600',
'Last-Modified': fileInfo.mtime,
'Accept-Ranges': 'bytes',
'Content-Type': getContentType(path.extname(filename).toLowerCase()),
'Content-Disposition': 'inline; filename*=UTF-8\'\'' + encodeURIComponent(filename),
'X-Content-Type-Options': 'nosniff'
});
// 处理范围请求
if (range) {
const ranges = rangeParser(fileInfo.size, range);
if (ranges === -1 || ranges === -2) {
return res.status(416).send('Range not satisfiable');
}
const { start, end } = ranges[0];
const chunk = end - start + 1;
res.status(206);
res.set({
'Content-Range': `bytes ${start}-${end}/${fileInfo.size}`,
'Content-Length': chunk
});
const stream = fs.createReadStream(filepath, {
start,
end,
highWaterMark: 64 * 1024 // 64KB 缓冲区
});
stats.totalBytes += chunk;
stats.requests += 1;
stream.on('error', (error) => {
console.error(`Stream error for ${filename}:`, error);
if (!res.headersSent) {
res.status(500).send('Internal server error');
}
});
stream.pipe(res);
} else {
res.set({
'Content-Length': fileInfo.size
});
const stream = fs.createReadStream(filepath, {
highWaterMark: 64 * 1024 // 64KB 缓冲区
});
stats.totalBytes += fileInfo.size;
stats.requests += 1;
stream.on('error', (error) => {
console.error(`Stream error for ${filename}:`, error);
if (!res.headersSent) {
res.status(500).send('Internal server error');
}
});
stream.pipe(res);
}
});
// 统计接口
app.get('/stats', (req, res) => {
res.json({
totalTransferred: bytes(stats.totalBytes),
totalRequests: stats.requests
});
});
// 下载音乐API
app.get('/api/download', async (req, res) => {
const { url, name } = req.query;
if (!url) {
return res.status(400).json({ error: 'Please provide a music url' });
}
// 从 URL 中获取文件名和扩展名
const urlFileName = decodeURIComponent(path.basename(url));
const urlExt = path.extname(urlFileName).toLowerCase();
if (!['.mp3', '.wav', '.flac', '.m4a'].includes(urlExt)) {
return res.status(400).json({ error: 'Unsupported file format' });
}
// 使用提供的文件名或 URL 中的文件名
const fullName = name ? (name + urlExt) : urlFileName;
// 验证文件名格式
if (!fullName.match(/^[a-zA-Z0-9\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fa5\s\-_.]+\.(mp3|wav|flac|m4a)$/)) {
return res.status(400).json({ error: 'filename is wrong' });
}
const savePath = path.join(musicDir, fullName);
// 检查文件是否已存在
if (fs.existsSync(savePath)) {
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
const fileUrl = `${protocol}://${host}/music/${encodeURIComponent(fullName)}`;
return res.status(200).json({
warning: 'The song already exists',
url: fileUrl
});
}
// api返回响应
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
res.json({
success: true,
message: 'The song added to download list successfully',
filename: fullName,
futureUrl: `${protocol}://${host}/music/${encodeURIComponent(fullName)}`,
});
// 将音乐加入后台异步下载
try {
const response = await axios({
method: 'GET',
url: url,
timeout: 300000,
responseType: 'stream'
});
const writer = fs.createWriteStream(savePath);
response.data.pipe(writer);
writer.on('error', (err) => {
console.error(`Download error for ${fullName}:`, err.message);
fs.unlink(savePath, () => {});
});
writer.on('finish', () => {
console.log(`Download finished ${fullName}`);
});
} catch (error) {
console.error(`Download failed for ${fullName}:`, error.message);
fs.unlink(savePath, () => {});
}
});
// 获取音乐文件大小
function formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)}${units[unitIndex]}`;
}
// 获取音乐列表 API
app.get('/api/music/list', async (req, res) => {
try {
const files = await fs.promises.readdir(musicDir);
const musicFiles = files.filter(file =>
['.mp3', '.wav', '.flac', '.m4a'].includes(path.extname(file).toLowerCase())
);
// 获取当前请求的完整URL
const currentUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const urlObj = new URL(currentUrl);
// 使用 x-forwarded-proto 头来判断实际协议
const protocol = req.headers['x-forwarded-proto'] || urlObj.protocol;
const host = urlObj.host;
const musicList = await Promise.all(musicFiles.map(async file => {
const filePath = path.join(musicDir, file);
const stat = await fs.promises.stat(filePath);
return {
filename: file,
url: `${protocol}://${host}/music/${encodeURIComponent(file)}`,
size: formatFileSize(stat.size),
extension: path.extname(file).slice(1).toUpperCase(),
lastModified: stat.mtime.toLocaleString()
};
}));
res.json({
total: musicList.length,
data: musicList
});
} catch (error) {
res.status(500).json({
error: 'Get music list failed',
details: error.message
});
}
});
// 删除音乐API - 使用POST请求
app.post('/api/delete/music', async (req, res) => {
const { names, password, all } = req.query;
// 验证管理密码
if (password !== ADMIN_PASSWORD) {
return res.status(401).json({ error: 'Unauthorized: Invalid password' });
}
try {
let filesToDelete = [];
// 情况1: 删除所有音乐文件
if (all === 'true') {
const files = await fs.promises.readdir(musicDir);
filesToDelete = files.filter(file =>
['.mp3', '.wav', '.flac', '.m4a'].includes(path.extname(file).toLowerCase())
);
}
// 情况2: 批量删除指定名称的音乐文件
else if (names) {
const nameList = typeof names === 'string' ? names.split(',') : names;
const files = await fs.promises.readdir(musicDir);
filesToDelete = files.filter(file => {
const filenameWithoutExt = path.basename(file, path.extname(file));
const songNamePart = filenameWithoutExt.split('-')[0].trim().toLowerCase();
return nameList.some(name =>
songNamePart === name.trim().toLowerCase() &&
['.mp3', '.wav', '.flac', '.m4a'].includes(path.extname(file).toLowerCase())
);
});
}
else {
return res.status(400).json({ error: 'Please provide names parameter or set all=true' });
}
if (filesToDelete.length === 0) {
return res.status(404).json({ error: 'No matching songs found' });
}
// 删除所有匹配的文件
await Promise.all(filesToDelete.map(async file => {
const filePath = path.join(musicDir, file);
await fs.promises.unlink(filePath);
cache.del(filePath);
}));
res.json({
success: true,
message: `Deleted ${filesToDelete.length} song(s)`,
deletedFiles: filesToDelete
});
} catch (error) {
res.status(500).json({
error: 'Failed to delete song(s)',
details: error.message
});
}
});
// 启动服务器
app.listen(PORT, () => {
console.log(`music service is running on port ${PORT}`);
});