|
|
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'; |
|
|
} |
|
|
|
|
|
|
|
|
const cache = new NodeCache({ |
|
|
stdTTL: 7200, |
|
|
checkperiod: 120, |
|
|
maxKeys: 500 |
|
|
}); |
|
|
|
|
|
|
|
|
const stats = { |
|
|
totalBytes: 0, |
|
|
requests: 0 |
|
|
}; |
|
|
|
|
|
|
|
|
app.set('json spaces', 2); |
|
|
|
|
|
|
|
|
app.use('/static', express.static(musicDir)); |
|
|
|
|
|
|
|
|
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 |
|
|
}); |
|
|
|
|
|
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 |
|
|
}); |
|
|
|
|
|
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 |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
|
|
|
|
|
|
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 |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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]}`; |
|
|
} |
|
|
|
|
|
|
|
|
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()) |
|
|
); |
|
|
|
|
|
|
|
|
const currentUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; |
|
|
const urlObj = new URL(currentUrl); |
|
|
|
|
|
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 |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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 = []; |
|
|
|
|
|
|
|
|
if (all === 'true') { |
|
|
const files = await fs.promises.readdir(musicDir); |
|
|
filesToDelete = files.filter(file => |
|
|
['.mp3', '.wav', '.flac', '.m4a'].includes(path.extname(file).toLowerCase()) |
|
|
); |
|
|
} |
|
|
|
|
|
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}`); |
|
|
}); |
|
|
|