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 = ""
}