Initial Arcana FX soundboard
This commit is contained in:
149
src/effects.js
vendored
Normal file
149
src/effects.js
vendored
Normal 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 };
|
||||
Reference in New Issue
Block a user