From 55f03123144aefb65a4ec084c28730b0e0c6d38b Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Mon, 17 Aug 2026 14:54:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(audio):=20=E6=94=AF=E6=8C=81=E6=96=9C?= =?UTF-8?q?=E6=9D=A0=E6=A0=BC=E5=BC=8F=E7=9A=84=E8=BD=A6=E7=AB=99=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E5=92=8C=E6=A8=A1=E7=B3=8A=E6=90=9C=E7=B4=A2=E5=8C=B9?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改TTS音频导入逻辑,保留编号中的斜杠(/)格式(如 CH-01/CE-12),自动创建子目录 - 移除generator.html中单文件和批量导入时的斜杠转横杠逻辑 - 新增sanitizeStationCode和ensureDirForFile辅助函数,确保路径安全 - 重写findFileCaseInsensitive,支持递归子目录搜索与末尾段模糊匹配 - 新增listAllFilesRecursive和resolveAudioPathWithFuzzy工具函数 - 修改getPlaybackAudioByBaseName/findWavByBaseName支持模糊搜索查找 - 修改resolveManagedPath允许子路径访问,getManagedFiles递归扫描子目录 - 媒体路由从:file参数改为通配符/*形式,safeRelativeToBase确保路径安全 - 升级版本号至v1.0.98 --- CloudServer/public/generator.html | 12 -- CloudServer/server.js | 332 ++++++++++++++++++------------ station_broadcast.lua | 5 +- 3 files changed, 204 insertions(+), 145 deletions(-) diff --git a/CloudServer/public/generator.html b/CloudServer/public/generator.html index 118464f..3cacf7f 100644 --- a/CloudServer/public/generator.html +++ b/CloudServer/public/generator.html @@ -1085,12 +1085,6 @@ const task = batchTasks[i]; if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones - // Replace slashes with dashes to ensure filesystem compatibility - if (task.code.includes('/')) { - console.log(`Replacing slashes in batch code: ${task.code}`); - task.code = task.code.replace(/\//g, '-'); - } - const statusSpan = document.getElementById(`batch-status-span-${i}`); statusSpan.textContent = 'Importing...'; statusSpan.style.color = 'var(--warning)'; @@ -1190,12 +1184,6 @@ return; } - // Replace slashes with dashes to ensure filesystem compatibility - if (code.includes('/')) { - console.log(`Replacing slashes in code: ${code}`); - code = code.replace(/\//g, '-'); - } - if (!zhTempFile || !jaTempFile) { showToast("Error", "No audio generated to import"); return; diff --git a/CloudServer/server.js b/CloudServer/server.js index 6be9baf..ef35636 100644 --- a/CloudServer/server.js +++ b/CloudServer/server.js @@ -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) { diff --git a/station_broadcast.lua b/station_broadcast.lua index 6beb7b8..f2b07dd 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -1,6 +1,6 @@ -- 1. Configuration & Constants -local APP_VERSION_TEXT = "v1.0.97" +local APP_VERSION_TEXT = "v1.0.98" local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group", @@ -61,7 +61,8 @@ local State = { updateInProgress = false, lastPeripheralScan = 0, lastScheduleSync = 0, - lastIdentitySync = 0 + lastIdentitySync = 0, + localCableGroup = "" }