feat(audio): 支持斜杠格式的车站编号和模糊搜索匹配

- 修改TTS音频导入逻辑,保留编号中的斜杠(/)格式(如 CH-01/CE-12),自动创建子目录
- 移除generator.html中单文件和批量导入时的斜杠转横杠逻辑
- 新增sanitizeStationCode和ensureDirForFile辅助函数,确保路径安全
- 重写findFileCaseInsensitive,支持递归子目录搜索与末尾段模糊匹配
- 新增listAllFilesRecursive和resolveAudioPathWithFuzzy工具函数
- 修改getPlaybackAudioByBaseName/findWavByBaseName支持模糊搜索查找
- 修改resolveManagedPath允许子路径访问,getManagedFiles递归扫描子目录
- 媒体路由从:file参数改为通配符/*形式,safeRelativeToBase确保路径安全
- 升级版本号至v1.0.98
This commit is contained in:
2026-08-17 14:54:21 +08:00
parent 849b43af39
commit 55f0312314
3 changed files with 204 additions and 145 deletions
+201 -131
View File
@@ -138,35 +138,57 @@ function sendBinaryFile(res, filePath) {
res.sendFile(filePath);
}
app.get('/media/audio/general/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name)));
function safeRelativeToBase(baseDir, relativePath) {
const clean = String(relativePath || '').replace(/\.\./g, '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!clean) return null;
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const full = path.join(baseDir, ...parts);
if (!full.startsWith(baseDir)) return null;
return full;
}
app.get('/media/audio/general/*', (req, res) => {
const rel = req.params[0] || '';
const candidates = getBeepAudioDirs().map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
sendBinaryFileFromCandidates(res, candidates);
});
app.get('/media/audio/beep/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name)));
app.get('/media/audio/beep/*', (req, res) => {
const rel = req.params[0] || '';
const candidates = getBeepAudioDirs().map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
sendBinaryFileFromCandidates(res, candidates);
});
app.get('/media/audio/music/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFile(res, path.join(audioMusicDir, name));
app.get('/media/audio/music/*', (req, res) => {
const rel = req.params[0] || '';
const full = safeRelativeToBase(audioMusicDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/voice/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFile(res, path.join(audioVoiceDir, name));
app.get('/media/audio/voice/*', (req, res) => {
const rel = req.params[0] || '';
const full = safeRelativeToBase(audioVoiceDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/line/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFile(res, path.join(audioLineDir, name));
app.get('/media/audio/line/*', (req, res) => {
const rel = req.params[0] || '';
const full = safeRelativeToBase(audioLineDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/station/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFile(res, path.join(audioStationDir, name));
app.get('/media/audio/station/*', (req, res) => {
const rel = req.params[0] || '';
const full = safeRelativeToBase(audioStationDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/temp/:file', (req, res) => {
const name = path.basename(String(req.params.file || ''));
sendBinaryFile(res, path.join(tempBroadcastDir, name));
app.get('/media/temp/*', (req, res) => {
const rel = req.params[0] || '';
const full = safeRelativeToBase(tempBroadcastDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media', (req, res) => {
@@ -873,18 +895,28 @@ function getWavDurationSec(buffer) {
return dataSize / (sampleRate * channels * (bitsPerSample / 8));
}
function resolveAudioPathWithFuzzy(dir, base, ext) {
const filename = `${base}${ext}`;
const direct = path.join(dir, filename);
if (fs.existsSync(direct)) return direct;
const result = findFileCaseInsensitive(dir, filename);
return result || null;
}
function getPlaybackAudioByBaseName(baseName) {
const base = normalizeBaseName(baseName);
if (!base) throw new Error('Invalid track name');
for (const ext of STREAMABLE_AUDIO_EXTENSIONS) {
const candidates = [
path.join(audioMusicDir, `${base}${ext}`),
...getBeepAudioDirs().map(dir => path.join(dir, `${base}${ext}`)),
path.join(audioStationDir, `${base}${ext}`)
const searchDirs = [
{ dir: audioMusicDir, kind: 'audio/music' },
...getBeepAudioDirs().map(d => ({ dir: d, kind: 'audio/beep' })),
{ dir: audioStationDir, kind: 'audio/station' },
{ dir: audioLineDir, kind: 'audio/station' }
];
for (const p of candidates) {
if (!fs.existsSync(p)) continue;
for (const { dir, kind } of searchDirs) {
const p = resolveAudioPathWithFuzzy(dir, base, ext);
if (!p) continue;
let durationSec = 0;
if (ext === '.wav') {
const buffer = fs.readFileSync(p);
@@ -893,22 +925,20 @@ function getPlaybackAudioByBaseName(baseName) {
return {
sourcePath: p,
audioFormat: 'stream',
streamUrl: getManagedMediaUrl(
p.startsWith(audioStationDir) ? 'audio/station' :
(p.startsWith(audioMusicDir) ? 'audio/music' : 'audio/beep'),
path.basename(p)
),
streamUrl: getManagedMediaUrl(kind, path.basename(p)),
durationSec
};
}
}
for (const p of [
path.join(audioMusicDir, `${base}.dfpwm`),
...getBeepAudioDirs().map(dir => path.join(dir, `${base}.dfpwm`)),
path.join(audioStationDir, `${base}.dfpwm`)
for (const { dir } of [
{ dir: audioMusicDir },
...getBeepAudioDirs().map(d => ({ dir: d })),
{ dir: audioStationDir },
{ dir: audioLineDir }
]) {
if (!fs.existsSync(p)) continue;
const p = resolveAudioPathWithFuzzy(dir, base, '.dfpwm');
if (!p) continue;
const buffer = fs.readFileSync(p);
return {
buffer,
@@ -1105,23 +1135,53 @@ function resolveManagedPath(inputPath) {
{ prefix: 'uploads/audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' },
{ prefix: 'uploads/audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'uploads/audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'Audio/line/', baseDir: audioLineDir, logicalPrefix: 'Audio/line/' },
{ prefix: 'uploads/audio/line/', baseDir: audioLineDir, logicalPrefix: 'Audio/line/' },
{ prefix: 'Audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' },
{ prefix: 'uploads/audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' }
];
for (const entry of prefixes) {
if (!normalized.startsWith(entry.prefix)) continue;
const relativeName = normalized.slice(entry.prefix.length).trim();
if (!relativeName || relativeName.includes('/') || relativeName.includes('\\')) return null;
let relativeName = normalized.slice(entry.prefix.length).trim();
if (!relativeName) return null;
relativeName = relativeName.replace(/\.\./g, '');
if (!relativeName) return null;
return {
logicalPath: `${entry.logicalPrefix}${relativeName}`,
fullPath: path.join(entry.baseDir, relativeName)
fullPath: path.join(entry.baseDir, ...relativeName.split('/'))
};
}
return null;
}
function scanDirFiles(baseDir, logicalPrefix, fileType, files, seenNames) {
if (!fs.existsSync(baseDir)) return;
const all = listAllFilesRecursive(baseDir, []);
for (const fullPath of all) {
let relative = path.relative(baseDir, fullPath).replace(/\\/g, '/');
const name = path.basename(relative);
if (name.endsWith('.dfpwm')) {
const wavName = name.replace(/\.dfpwm$/, '.wav');
const wavRelative = relative.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(baseDir, ...wavRelative.split('/')))) continue;
}
if (seenNames) {
if (seenNames.has(relative)) continue;
seenNames.add(relative);
}
const stats = fs.statSync(fullPath);
files.push({
name: relative,
path: `${logicalPrefix}${relative}`,
type: fileType,
size: stats.size,
mtime: stats.mtimeMs
});
}
}
function getManagedFiles() {
const files = [];
@@ -1136,98 +1196,34 @@ function getManagedFiles() {
});
}
if (fs.existsSync(audioMusicDir)) {
fs.readdirSync(audioMusicDir).forEach(file => {
if (file.endsWith('.dfpwm')) {
const wavName = file.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(audioMusicDir, wavName))) return;
}
const filePath = path.join(audioMusicDir, file);
const stats = fs.statSync(filePath);
files.push({
name: file,
path: `Audio/music/${file}`,
type: 'music',
size: stats.size,
mtime: stats.mtimeMs
});
});
}
if (fs.existsSync(audioVoiceDir)) {
fs.readdirSync(audioVoiceDir).forEach(file => {
if (file.endsWith('.dfpwm')) {
const wavName = file.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(audioVoiceDir, wavName))) return;
}
const filePath = path.join(audioVoiceDir, file);
const stats = fs.statSync(filePath);
files.push({
name: file,
path: `Audio/voice/${file}`,
type: 'voice',
size: stats.size,
mtime: stats.mtimeMs
});
});
}
if (fs.existsSync(audioLineDir)) {
fs.readdirSync(audioLineDir).forEach(file => {
if (file.endsWith('.dfpwm')) {
const wavName = file.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(audioLineDir, wavName))) return;
}
const filePath = path.join(audioLineDir, file);
const stats = fs.statSync(filePath);
files.push({
name: file,
path: `Audio/line/${file}`,
type: 'line',
size: stats.size,
mtime: stats.mtimeMs
});
});
}
scanDirFiles(audioMusicDir, 'Audio/music/', 'music', files);
scanDirFiles(audioVoiceDir, 'Audio/voice/', 'voice', files);
scanDirFiles(audioLineDir, 'Audio/line/', 'line', files);
scanDirFiles(audioStationDir, 'Audio/station/', 'station', files);
const beepSeen = new Set();
for (const dir of getBeepAudioDirs()) {
if (!fs.existsSync(dir)) continue;
fs.readdirSync(dir).forEach(file => {
if (beepSeen.has(file)) return;
beepSeen.add(file);
if (file.endsWith('.dfpwm')) {
const wavName = file.replace(/\.dfpwm$/, '.wav');
if (beepSeen.has(wavName) || fs.existsSync(path.join(dir, wavName))) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const name = entry.name;
if (beepSeen.has(name)) continue;
beepSeen.add(name);
if (name.endsWith('.dfpwm')) {
const wavName = name.replace(/\.dfpwm$/, '.wav');
if (beepSeen.has(wavName) || fs.existsSync(path.join(dir, wavName))) continue;
}
const filePath = path.join(dir, file);
const filePath = path.join(dir, name);
const stats = fs.statSync(filePath);
files.push({
name: file,
path: `Audio/beep/${file}`,
type: file === 'line.txt' ? 'config' : 'beep',
name: name,
path: `Audio/beep/${name}`,
type: name === 'line.txt' ? 'config' : 'beep',
size: stats.size,
mtime: stats.mtimeMs
});
});
}
if (fs.existsSync(audioStationDir)) {
fs.readdirSync(audioStationDir).forEach(file => {
if (file.endsWith('.dfpwm')) {
const wavName = file.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(audioStationDir, wavName))) return;
}
const filePath = path.join(audioStationDir, file);
const stats = fs.statSync(filePath);
files.push({
name: file,
path: `Audio/station/${file}`,
type: 'station',
size: stats.size,
mtime: stats.mtimeMs
});
});
}
}
return files;
@@ -1449,12 +1445,60 @@ function getOrCreateHlsStreamState(streamName) {
return hlsStreams[streamName];
}
function listAllFilesRecursive(dir, extensions) {
if (!fs.existsSync(dir)) return [];
const results = [];
function walk(currentDir) {
let entries;
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
if (!extensions || extensions.length === 0) {
results.push(fullPath);
} else {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
}
}
walk(dir);
return results;
}
function findFileCaseInsensitive(dir, filename) {
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir);
const lowerFilename = filename.toLowerCase();
const match = files.find(f => f.toLowerCase() === lowerFilename);
return match ? path.join(dir, match) : null;
const ext = path.extname(lowerFilename);
const baseWithoutExt = lowerFilename.slice(0, lowerFilename.length - ext.length);
const suffixMatchPattern = '/' + baseWithoutExt + ext;
const suffixMatchPattern2 = '\\' + baseWithoutExt + ext;
try {
const files = fs.readdirSync(dir);
const directMatch = files.find(f => f.toLowerCase() === lowerFilename);
if (directMatch) {
return path.join(dir, directMatch);
}
} catch {}
const allFiles = listAllFilesRecursive(dir, [ext]);
for (const fullPath of allFiles) {
const normalized = fullPath.toLowerCase();
if (normalized.endsWith(suffixMatchPattern) || normalized.endsWith(suffixMatchPattern2)) {
return fullPath;
}
}
return null;
}
function resolveSequenceFiles(seq) {
@@ -3133,6 +3177,21 @@ app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }
}
});
function sanitizeStationCode(code) {
let s = String(code || '').trim();
s = s.replace(/\\/g, '/');
s = s.replace(/\.\./g, '');
s = s.split('/').map(part => part.replace(/[<>:"|?*]/g, '').trim()).filter(Boolean).join('/');
return s;
}
function ensureDirForFile(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// Import TTS temp files to station folder
app.post('/api/tts/import', express.json(), (req, res) => {
const stationCode = String(req.body?.stationCode || '').trim();
@@ -3143,13 +3202,16 @@ app.post('/api/tts/import', express.json(), (req, res) => {
const zhSource = path.join(uploadDir, zhTemp);
const jaSource = path.join(uploadDir, jaTemp);
const safeStationCode = String(stationCode).replace(/\//g, '-');
const safeStationCode = sanitizeStationCode(stationCode);
if (!safeStationCode) return res.status(400).json({ success: false, message: 'Invalid station code' });
const targetDir = type === 'line' ? audioLineDir : audioStationDir;
const zhDest = path.join(targetDir, `ch-${safeStationCode}.wav`);
const jaDest = path.join(targetDir, `ja-${safeStationCode}.wav`);
try {
ensureDirForFile(zhDest);
ensureDirForFile(jaDest);
if (fs.existsSync(zhSource)) {
fs.copyFileSync(zhSource, zhDest);
fs.unlinkSync(zhSource);
@@ -3175,12 +3237,20 @@ function safeFilename(name) {
function findWavByBaseName(baseName) {
const safeBase = String(baseName || '').trim().replace(/\.wav$/i, '');
if (!safeBase) return null;
const candidates = [
...getBeepAudioDirs().map(dir => path.join(dir, `${safeBase}.wav`)),
path.join(audioStationDir, `${safeBase}.wav`),
path.join(uploadDir, `${safeBase}.wav`)
const filename = `${safeBase}.wav`;
const searchDirs = [
...getBeepAudioDirs(),
audioStationDir,
audioLineDir,
uploadDir
];
return candidates.find(p => fs.existsSync(p)) || null;
for (const dir of searchDirs) {
const direct = path.join(dir, filename);
if (fs.existsSync(direct)) return direct;
const found = findFileCaseInsensitive(dir, filename);
if (found) return found;
}
return null;
}
function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {