Group duplicate effects and add loop variants

This commit is contained in:
Hermes Agent
2026-05-21 00:31:53 +02:00
parent 23391a2d3e
commit fda46887ed
5 changed files with 482 additions and 67 deletions

227
src/effects.js vendored
View File

@@ -3,13 +3,20 @@
const path = require('node:path');
/**
* Walks the Filestash share recursively, collects audio files,
* Walks the Filestash share recursively, collects audio files, folds them
* into logical groups (numbered variants, format duplicates, loop markers)
* 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.
* Effects are identified internally by their share-relative path (e.g.
* "/Combat/Sword.mp3"). The public API also exposes stable group IDs
* derived from category + base name. Clients may play by groupId/variantId
* (preferred) or path (legacy). The media proxy always validates by path.
*/
// Format preference when multiple extensions exist for the same logical sound.
// Earlier = preferred. Adjust here if data suggests otherwise.
const FORMAT_PREFERENCE = ['ogg', 'mp3', 'm4a', 'wav', 'flac'];
class EffectsService {
constructor({ client, extensions, ignoreNames, cacheTtlSeconds, maxDepth }) {
this.client = client;
@@ -18,6 +25,8 @@ class EffectsService {
this.cacheTtlMs = Math.max(0, cacheTtlSeconds * 1000);
this.maxDepth = Math.max(1, maxDepth);
this.cache = null;
this.groupsCache = null;
this.groupsById = null;
this.cacheLoadedAt = 0;
this.inflight = null;
}
@@ -41,7 +50,7 @@ class EffectsService {
}
/**
* Returns the cached effects list, refreshing if expired or forced.
* Returns the cached flat effects list, refreshing if expired or forced.
* Single-flight: concurrent callers share the same refresh promise.
*/
async list({ force = false } = {}) {
@@ -59,6 +68,11 @@ class EffectsService {
return this.inflight;
}
async listGroups({ force = false } = {}) {
await this.list({ force });
return this.groupsCache || [];
}
async _refresh() {
const effects = [];
const stack = [{ rel: '/', depth: 0 }];
@@ -93,24 +107,38 @@ class EffectsService {
if (!this.isAudioFile(entry.name)) continue;
const category = deriveCategory(childRel);
const display = stripExt(entry.name);
const parsed = parseDisplayName(display);
effects.push({
id: childRel,
path: childRel,
name: stripExt(entry.name),
name: display,
fileName: entry.name,
category,
type: path.extname(entry.name).slice(1).toLowerCase(),
size: entry.size,
baseName: parsed.baseName,
variantNumber: parsed.variantNumber,
isLoop: parsed.isLoop,
});
}
}
effects.sort((a, b) => {
const c = a.category.localeCompare(b.category);
return c !== 0 ? c : a.name.localeCompare(b.name);
if (c !== 0) return c;
const bn = a.baseName.localeCompare(b.baseName);
if (bn !== 0) return bn;
const an = (a.variantNumber || 0) - (b.variantNumber || 0);
if (an !== 0) return an;
return a.name.localeCompare(b.name);
});
const { groups, groupsById } = buildGroups(effects);
this.cache = effects;
this.groupsCache = groups;
this.groupsById = groupsById;
this.cacheLoadedAt = Date.now();
return effects;
}
@@ -120,6 +148,37 @@ class EffectsService {
const list = await this.list();
return list.find((e) => e.path === relPath) || null;
}
async findGroup(groupId) {
if (typeof groupId !== 'string' || !groupId) return null;
await this.list();
return (this.groupsById && this.groupsById.get(groupId)) || null;
}
/**
* Resolves a group + optional variantId to a concrete effect (one file path).
* If variantId is omitted, picks one variant (rotating to avoid immediate
* repeat on the server side as a fallback when the client doesn't specify).
*/
async resolveGroupVariant(groupId, variantId) {
const group = await this.findGroup(groupId);
if (!group) return null;
let variant = null;
if (variantId) {
variant = group.variants.find((v) => v.id === variantId) || null;
if (!variant) return null;
} else {
if (!group.variants.length) return null;
const idx = group._nextPick % group.variants.length;
variant = group.variants[idx];
group._nextPick = (group._nextPick + 1) % Math.max(1, group.variants.length);
}
const effect = await this.findByPath(variant.path);
if (!effect) return null;
return { group, variant, effect };
}
}
function joinRel(parent, name) {
@@ -138,6 +197,151 @@ function stripExt(name) {
return ext ? name.slice(0, -ext.length) : name;
}
/**
* Parses a display name (without extension) into { baseName, variantNumber, isLoop }.
* Rules:
* - "(loop)" anywhere is stripped and sets isLoop. Case insensitive.
* - A trailing " <digits>" (after optional whitespace) is parsed as a variant
* number and stripped from the base name. "Sword Hit 2" -> base "Sword Hit", n=2.
* - Multiple internal spaces collapsed.
*/
function parseDisplayName(name) {
let working = String(name || '').trim();
let isLoop = false;
if (/\(\s*loop\s*\)/i.test(working)) {
isLoop = true;
working = working.replace(/\(\s*loop\s*\)/ig, ' ').replace(/\s+/g, ' ').trim();
}
let variantNumber = null;
const m = working.match(/^(.*?)[\s_-]+(\d{1,4})$/);
if (m) {
variantNumber = Number(m[2]);
working = m[1].trim();
}
if (!working) working = name; // fallback so empties don't collapse
return { baseName: working, variantNumber, isLoop };
}
function groupKey(category, baseName, isLoop) {
return [
'g',
slug(category),
slug(baseName),
isLoop ? 'loop' : 'one',
].join('::');
}
function variantKey(number) {
return number == null ? 'default' : `n${number}`;
}
function slug(s) {
return String(s || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'x';
}
/**
* Picks the preferred file when several formats represent the same variant.
*/
function pickPreferred(files) {
const sorted = [...files].sort((a, b) => {
const ai = FORMAT_PREFERENCE.indexOf(a.type);
const bi = FORMAT_PREFERENCE.indexOf(b.type);
const ar = ai === -1 ? FORMAT_PREFERENCE.length : ai;
const br = bi === -1 ? FORMAT_PREFERENCE.length : bi;
if (ar !== br) return ar - br;
return a.path.localeCompare(b.path);
});
return sorted[0];
}
function buildGroups(effects) {
const byGroup = new Map();
for (const e of effects) {
const key = groupKey(e.category, e.baseName, e.isLoop);
let group = byGroup.get(key);
if (!group) {
group = {
id: key,
name: e.baseName,
category: e.category,
isLoop: e.isLoop,
_variantBuckets: new Map(),
};
byGroup.set(key, group);
}
const vKey = variantKey(e.variantNumber);
let bucket = group._variantBuckets.get(vKey);
if (!bucket) {
bucket = { id: vKey, number: e.variantNumber, files: [] };
group._variantBuckets.set(vKey, bucket);
}
bucket.files.push(e);
}
const groups = [];
const groupsById = new Map();
for (const group of byGroup.values()) {
const variants = [];
for (const bucket of group._variantBuckets.values()) {
const preferred = pickPreferred(bucket.files);
variants.push({
id: bucket.id,
number: bucket.number,
name: preferred.name,
path: preferred.path,
type: preferred.type,
formats: [...new Set(bucket.files.map((f) => f.type))],
allPaths: bucket.files.map((f) => f.path),
});
}
variants.sort((a, b) => {
if (a.number == null && b.number == null) return a.name.localeCompare(b.name);
if (a.number == null) return -1;
if (b.number == null) return 1;
return a.number - b.number;
});
const numberedVariants = variants.filter((v) => v.number != null);
const hasNumberedVariants = numberedVariants.length >= 2;
const tags = [group.category];
if (group.isLoop) tags.push('loop');
if (hasNumberedVariants) tags.push(`${numberedVariants.length} takes`);
const out = {
id: group.id,
name: group.name,
category: group.category,
isLoop: group.isLoop,
tags,
hasNumberedVariants,
variantCount: variants.length,
variants,
// legacy: flat list of every path under this group, used by clients
// (and tests) that want to discover all underlying files.
paths: variants.flatMap((v) => v.allPaths),
_nextPick: 0,
};
delete group._variantBuckets;
groups.push(out);
groupsById.set(out.id, out);
}
groups.sort((a, b) => {
const c = a.category.localeCompare(b.category);
if (c !== 0) return c;
return a.name.localeCompare(b.name);
});
return { groups, groupsById };
}
function isSafeRel(p) {
if (typeof p !== 'string' || !p.length) return false;
if (!p.startsWith('/')) return false;
@@ -146,4 +350,11 @@ function isSafeRel(p) {
return true;
}
module.exports = { EffectsService, isSafeRel };
module.exports = {
EffectsService,
isSafeRel,
// exported for internal use / tests
parseDisplayName,
buildGroups,
FORMAT_PREFERENCE,
};

View File

@@ -30,8 +30,15 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
try {
const force = req.query.refresh === '1' || req.query.refresh === 'true';
const list = await effects.list({ force });
const groups = await effects.listGroups();
const categories = [...new Set(list.map((e) => e.category))].sort();
res.json({ count: list.length, categories, effects: list });
res.json({
count: list.length,
groupCount: groups.length,
categories,
effects: list,
groups: groups.map(publicGroup),
});
} catch (err) {
next(err);
}
@@ -81,12 +88,44 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
router.post('/api/play', express.json({ limit: '8kb' }), async (req, res, next) => {
try {
const { path: effectPath, volume } = req.body || {};
if (!isSafeRel(effectPath)) return res.status(400).json({ error: 'invalid effect path' });
const effect = await effects.findByPath(effectPath);
if (!effect) return res.status(404).json({ error: 'effect not found' });
const body = req.body || {};
const { groupId, variantId, path: effectPath, volume, loop: loopOverride } = body;
const injected = await mixer.play(effect, { volume });
let effect = null;
let resolvedGroupId = null;
let resolvedVariantId = null;
let isLoop = false;
let displayName = null;
if (typeof groupId === 'string' && groupId) {
const resolved = await effects.resolveGroupVariant(
groupId,
typeof variantId === 'string' ? variantId : null,
);
if (!resolved) return res.status(404).json({ error: 'group or variant not found' });
effect = resolved.effect;
resolvedGroupId = resolved.group.id;
resolvedVariantId = resolved.variant.id;
isLoop = resolved.group.isLoop;
displayName = resolved.variant.number != null
? `${resolved.group.name} ${resolved.variant.number}`
: resolved.group.name;
} else {
if (!isSafeRel(effectPath)) return res.status(400).json({ error: 'invalid effect path' });
effect = await effects.findByPath(effectPath);
if (!effect) return res.status(404).json({ error: 'effect not found' });
isLoop = Boolean(effect.isLoop);
}
const loop = typeof loopOverride === 'boolean' ? loopOverride : isLoop;
const injected = await mixer.play(effect, {
volume,
loop,
groupId: resolvedGroupId,
variantId: resolvedVariantId,
displayName,
});
res.json({ ok: true, injected, stream: mixer.stats(), entity: allowedEntity });
} catch (err) {
next(err);
@@ -154,4 +193,32 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
return router;
}
/**
* Strips internal-only fields from a group before sending it to clients.
* Underlying paths stay in `variants[].allPaths` because the UI uses them
* for search and best-effort migration of pre-grouping favourites. The
* media proxy still validates by path against the indexed effect list, so
* exposing already-indexed paths does not widen access.
*/
function publicGroup(g) {
return {
id: g.id,
name: g.name,
category: g.category,
isLoop: g.isLoop,
tags: g.tags,
hasNumberedVariants: g.hasNumberedVariants,
variantCount: g.variantCount,
variants: g.variants.map((v) => ({
id: v.id,
number: v.number,
name: v.name,
path: v.path,
type: v.type,
formats: v.formats,
allPaths: v.allPaths,
})),
};
}
module.exports = { createApiRouter };

View File

@@ -42,7 +42,7 @@ class StreamMixer {
});
}
async play(effect, { volume = 1 } = {}) {
async play(effect, { volume = 1, loop = false, groupId = null, variantId = null, displayName = null } = {}) {
if (!effect || !effect.path) {
const err = new Error('effect is required');
err.status = 400;
@@ -58,9 +58,12 @@ class StreamMixer {
const item = {
id,
path: effect.path,
name: effect.name,
name: displayName || effect.name,
category: effect.category,
type: effect.type,
groupId,
variantId,
loop: Boolean(loop),
volume: clamp(Number(volume) || 1, 0, 2),
startedAt: Date.now(),
queue: [],
@@ -72,6 +75,7 @@ class StreamMixer {
const args = [
'-hide_banner', '-loglevel', 'error',
...(item.loop ? ['-stream_loop', '-1'] : []),
'-i', mediaUrl,
'-f', 's16le',
'-acodec', 'pcm_s16le',
@@ -238,6 +242,9 @@ function publicItem(item) {
name: item.name,
category: item.category,
type: item.type,
groupId: item.groupId || null,
variantId: item.variantId || null,
loop: Boolean(item.loop),
volume: item.volume,
startedAt: item.startedAt,
ageMs: Date.now() - item.startedAt,