Initial Arcana FX soundboard

This commit is contained in:
Hermes Agent
2026-05-20 23:51:18 +02:00
commit 232e6923e3
19 changed files with 2520 additions and 0 deletions

149
src/effects.js vendored Normal file
View File

@@ -0,0 +1,149 @@
'use strict';
const path = require('node:path');
/**
* Walks the Filestash share recursively, collects audio files,
* and caches the result in memory with a TTL.
*
* Effects are identified by their share-relative path (e.g. "/Combat/Sword.mp3").
* This identity is what the API accepts to play or proxy a file — clients
* must never pass an arbitrary absolute Filestash path.
*/
class EffectsService {
constructor({ client, extensions, ignoreNames, cacheTtlSeconds, maxDepth }) {
this.client = client;
this.extensions = new Set(extensions.map((e) => e.toLowerCase()));
this.ignore = new Set(ignoreNames.map((n) => n.toLowerCase()));
this.cacheTtlMs = Math.max(0, cacheTtlSeconds * 1000);
this.maxDepth = Math.max(1, maxDepth);
this.cache = null;
this.cacheLoadedAt = 0;
this.inflight = null;
}
isAudioFile(name) {
const lower = name.toLowerCase();
if (this.ignore.has(lower)) return false;
const ext = path.extname(lower);
return this.extensions.has(ext);
}
contentTypeFor(name) {
switch (path.extname(name).toLowerCase()) {
case '.mp3': return 'audio/mpeg';
case '.ogg': return 'audio/ogg';
case '.flac': return 'audio/flac';
case '.wav': return 'audio/wav';
case '.m4a': return 'audio/mp4';
default: return 'application/octet-stream';
}
}
/**
* Returns the cached effects list, refreshing if expired or forced.
* Single-flight: concurrent callers share the same refresh promise.
*/
async list({ force = false } = {}) {
const fresh =
this.cache &&
!force &&
Date.now() - this.cacheLoadedAt < this.cacheTtlMs;
if (fresh) return this.cache;
if (!this.inflight) {
this.inflight = this._refresh().finally(() => {
this.inflight = null;
});
}
return this.inflight;
}
async _refresh() {
const effects = [];
const stack = [{ rel: '/', depth: 0 }];
const visited = new Set();
while (stack.length) {
const { rel, depth } = stack.pop();
if (visited.has(rel)) continue;
visited.add(rel);
let entries;
try {
entries = await this.client.listDir(rel);
} catch (err) {
if (rel === '/') throw err;
// Skip unreadable sub-folders instead of aborting the whole scan.
// eslint-disable-next-line no-console
console.warn(`[effects] failed to list ${rel}: ${err.message}`);
continue;
}
for (const entry of entries) {
if (!entry.name || entry.name === '.' || entry.name === '..') continue;
if (this.ignore.has(entry.name.toLowerCase())) continue;
const childRel = joinRel(rel, entry.name);
if (entry.type === 'directory') {
if (depth + 1 < this.maxDepth) {
stack.push({ rel: childRel + '/', depth: depth + 1 });
}
continue;
}
if (!this.isAudioFile(entry.name)) continue;
const category = deriveCategory(childRel);
effects.push({
id: childRel,
path: childRel,
name: stripExt(entry.name),
fileName: entry.name,
category,
type: path.extname(entry.name).slice(1).toLowerCase(),
size: entry.size,
});
}
}
effects.sort((a, b) => {
const c = a.category.localeCompare(b.category);
return c !== 0 ? c : a.name.localeCompare(b.name);
});
this.cache = effects;
this.cacheLoadedAt = Date.now();
return effects;
}
async findByPath(relPath) {
if (!isSafeRel(relPath)) return null;
const list = await this.list();
return list.find((e) => e.path === relPath) || null;
}
}
function joinRel(parent, name) {
const p = parent.endsWith('/') ? parent.slice(0, -1) : parent;
return `${p}/${name}`;
}
function deriveCategory(relPath) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length <= 1) return 'Uncategorized';
return parts[parts.length - 2];
}
function stripExt(name) {
const ext = path.extname(name);
return ext ? name.slice(0, -ext.length) : name;
}
function isSafeRel(p) {
if (typeof p !== 'string' || !p.length) return false;
if (!p.startsWith('/')) return false;
if (p.includes('..')) return false;
if (p.includes('\0')) return false;
return true;
}
module.exports = { EffectsService, isSafeRel };