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:
+201
-131
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user